<?php
use frontend\modules\yii2\widgets\layouts\markdown\Markdown;

$this->title = 'Api控制器使用教程';
$this->registerMetaTag([
    'name' => 'keywords',
    'content' => 'yii,yii2,框架,应用结构,web,controller,api,RESTful,请求,接口,控制器,使用,教程'
]);
$this->registerMetaTag([
    'name' => 'description',
    'content' => 'api的控制器是专门处理接口请求的控制器类型，本章介绍如何创建api控制器，以及相关权限控制策略、RESTful api的概念等内容。'
]);
$this->registerMetaTag([
    'name' => 'author',
    'content' => 'chenzhiwei'
]);


$html = [
    "概述" => <<<HTML
# Api控制器使用教程
Api控制器是专门处理接口请求的控制器类型，它不像web类控制器一样有session和cookie保存用户登录状态，也没有console控制器的完全服务端化运行机制，它的应用场景主要是给第三方系统提供数据访问功能，如：ios和android应用。本章介绍如何创建api控制器，以及相关权限控制策略、RESTful api等内容。

HTML,
    "应用场景" => <<<HTML
## 应用场景 id=scenario
Api控制器继承自Web控制器，因此应用场景比较丰富，可以分为以下几类：
- Ios和Android应用的服务端（无浏览器，不能使用session和cookie）；
- Vue等前端框架实现的完全前后端分离的服务端（有浏览器，可以使用session和cookie）；
- 非前后端完全分离开发模式下的ajax等数据访问接口开发；
- 第三方系统的数据来源（服务端对服务端），如：微信公众号接口。

根据应用场景不同，需要采用不同的权限控制策略：

| 应用场景 | 浏览器 | CSRF | 权限控制 |
|---|---|---|---|
|Ios和Android| 无 | 不需要 | access token |
|网页应用| 有 | 需要 | acf / rbac |
|服务端对服务端| 无 | 不需要 | Oauth2 |

HTML,
    "父类" => <<<HTML
## 父类 id=father-class
Api控制器继承自Web控制器（[yii\\web\Controller](https://www.yiichina.com/doc/api/2.0/yii-web-controller)），根据使用场景不同，又派生了两种控制器：
- 通用型 RESTful Api控制器（[yii\\rest\Controller](https://www.yiichina.com/doc/api/2.0/yii-rest-controller)）；
- 支持对 ActiveRecord 的 RESTful Api控制器（[yii\\rest\ActiveController](https://www.yiichina.com/doc/api/2.0/yii-rest-activecontroller)）；

```php
namespace app\controllers;

use yii\\rest\Controller;

class UserController extends Controller
{
 ... ...
}
``` 

HTML,
    "处理步骤" => <<<HTML
## 处理步骤 id=steps
RESTful API 在请求处理周期中会依次实现以下步骤：
- 根据请求信息进行内容协商设置响应格式（参见 [yii\\filters\ContentNegotiator](https://www.yiichina.com/doc/api/2.0/yii-filters-contentnegotiator)）;
- 验证请求方法（POST/GET/DELETE...）（参见 [verbs()](https://www.yiichina.com/doc/api/2.0/yii-rest-controller#verbs()-detail)）;
- 权限控制 （参见 [yii\\filters\auth\AuthInterface](https://www.yiichina.com/doc/api/2.0/yii-filters-auth-authinterface)）;
- 限制接口访问频率 （参见 [yii\\filters\RateLimiter](https://www.yiichina.com/doc/api/2.0/yii-filters-ratelimiter)）;
- 根据第一步配置结果格式化响应数据（参见 [serializeData()](https://www.yiichina.com/doc/api/2.0/yii-rest-controller#serializeData()-detail)）。

HTML,
    "内容协商" => <<<HTML
### 内容协商 id=ContentNegotiator
内容协商是指Api控制器提供了一种机制可以让客户端来决定要返回的数据格式；

HTML
];
echo Markdown::widget(["content" => implode("\n", $html)]);

?>

<div class="row">
    <div class="col-lg-6 col-md-6 col-sm-6 col-xs-12 col-lg-offset-3 col-md-offset-3 col-sm-offset-3 cell">
        <div>
            <button class="btn btn-xs btn-info">Codelab</button>
            <button class="btn btn-xs btn-success">RESTful API</button>
            <button class="btn btn-xs btn-warning">Content Negotiator</button><br/>
            <span>RESTful API 内容协商应用举例</span>
            <p>在本实验中将用一个完整的案例演示如何使用 RESTful API 的内容协商功能，希望读者通过此案例了解内容协商的概念。</p>
            <p><a class="btn btn-default" href="/codelab/api/content-negotiator">开始 &raquo;</a></p>
        </div>
    </div>
</div>

<?php
$html = [
    "内容协商" => <<<HTML
内容协商的配置主要是针对响应格式和语言，您可以在三个地方进行配置：
- 在应用主体的配置文件中配置：
```php
// in application configuration
use yii\web\Response;
return [
    'bootstrap' => [
        [
            'class' => 'yii\\filters\ContentNegotiator',
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
                'application/xml' => Response::FORMAT_XML,
            ],
            'languages' => [
                'en',
                'de',
            ],
        ],
    ],
];
```
- 在控制器的过滤器中配置：
```php
use yii\web\Response;
public function behaviors()
{
    return [
        [
            'class' => 'yii\\filters\ContentNegotiator',
            'only' => ['view', 'index'],
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
            ],
            'languages' => [
                'en',
                'de',
            ],
        ],
    ];
}
```
- 在模块（Modules.php）中配置：
 ```php
use yii\web\Response;
public function behaviors()
{
    return [
        [
            'class' => 'yii\\filters\ContentNegotiator',
            // if in a module, use the following IDs for user actions
            'only' => ['user/view', 'user/index'],
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
            ],
            'languages' => [
                'en',
                'de',
            ],
        ],
    ];
}
```
> 由于api控制器属于可以被客户端访问的应用，因此建议放置在frontend前端应用中，但是其具备独特的授权机制，我们一般以模块的方式进行封装和管理，所以在Api模块文件中定义内容协商是最合适的。

客户端对内容协商的使用有两种方式：
- Accept协议头配置
```php
curl -i -H "Accept:application/json" "http://127.0.0.1:8080/api/user/info"
```
- GET方法配置，使用内置参数`_format`来配置：
```php
curl -i "http://127.0.0.1:8080/api/user/info?_format=json"
```


HTML,
    "verb" => <<<HTML
### 方法过滤 id=verbs
这是对请求方法（POST/GET/DELETE/...）的限制，`yii\\rest\Controller`提供了一个方法（`verbs()`）让我们来设置过滤器：`vendor/yiisoft/yii2/rest/Controller.php`
```php
public function behaviors()
{
    return [
        'contentNegotiator' => [
            'class' => ContentNegotiator::className(),
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
                'application/xml' => Response::FORMAT_XML,
            ],
        ],
        'verbFilter' => [
            'class' => VerbFilter::className(),
            'actions' => \$this->verbs(),
        ],
        'authenticator' => [
            'class' => CompositeAuth::className(),
        ],
        'rateLimiter' => [
            'class' => RateLimiter::className(),
        ],
    ];
}
```
过滤器配置如下：
```php
protected function verbs()
{
    return [
        'index' => ['GET'],
        'view' => ['GET'],
        'create' => ['GET', 'POST'],
        'update' => ['GET', 'PUT', 'POST'],
        'delete' => ['POST', 'DELETE'],
        
        // 允许所有的动作使用GET
        //'*' => ['GET'], 
    ];
}
```
> **注意：**没有在`verbs()`中列出的动作（action）不受任何限制。

HTML,
    "权限控制" => <<<HTML
### 权限控制 id=auth
Yii给我们提供了如下几种认证方式：
- HTTP 基本认证（[yii\\filters\auth\HttpBasicAuth](https://www.yiichina.com/doc/api/2.0/yii-filters-auth-httpbasicauth)）
- OAuth2认证（[yii\\filters\auth\HttpBearerAuth](https://www.yiichina.com/doc/api/2.0/yii-filters-auth-httpbearerauth)）
- 参数认证（access token）

#### HTTP 基本认证 id=http-basic-auth
这种方式需要将账号密码需要传入到`\$_SERVER['PHP_AUTH_USER']`和`\$_SERVER['PHP_AUTH_PW']`中，然后会被`authHttpBasicAuth`读取进行权限认证。配置方法如下：
```php
public function behaviors()
{
    return [
        'authenticator' => [
            'class' => HttpBasicAuth::className(),
        ],
    ];
}
```
当你访问目标接口的时候会弹出如下对话框，你需要填写账号密码才能访问：
![HttpBasicAuth登录页面](/images/20211220100507.png "HttpBasicAuth登录页面")
这种方法有如下缺点：
1. 需要浏览器支持；
2. 重启浏览器后需要重新登录；

#### OAuth2认证 id=oauth2
这是一种管理第三方应用权限的授权机制，你一定有过手机app中以微信/QQ/微博账号登录本应用的体验，这就是OAuth2，它允许不获取用户的账号和密码的情况下得到第三方应用中的用户信息。  

OAuth2的工作步骤一般分为2步：
1. 通过第三方（用户信息存储方）提供的`Client_ID`和`Secret_ID`获得授权（通常会下发一个code）；
2. 拿到授权后即可获取`Access Token`，然后通过`Access Token`就可以访问第三方用户资源了；

Yii官方框架中没有提供上述第一步的实现，但是却实现了第二步的功能，我们可以使用`authHttpBearerAuth`来实现权限验证：
```php
namespace frontend\controllers;

use yii\\rest\Controller;
use yii\\filters\auth\HttpBearerAuth;

/**
 * Api controller
 */
class ApiController extends Controller
{
    public function behaviors()
    {
        return [
            'authenticator' => [
                'class' => HttpBearerAuth::className(),
            ],
        ];
    }

    public function actionUser(){
        return ["message" => "hello"];
    }
}
```
`HttpBearerAuth`会读取请求头中`Authorization:Bearer [Access Token]`的值，注意，您需要把`[Access Token]`替换为真实的token值，举例如下：
```php
curl -i -H "Authorization:Bearer 2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH" "http://127.0.0.1:20080/api/user"
```
`Access Token`的验证需要认证组件的配合：`frontend/config/main.php`
```php
return [
    ... ...
    'components' => [
        'user' => [
            'identityClass' => 'common\models\User',
        ],
        ... ...
    ]
];
```
上述配置的重点是`identityClass`所指定的类（`common\models\User`），和它的`Access Token`处理方法：
```php
public static function findIdentityByAccessToken(\$token, \$type = null)
{
    // 认证成功返回User对象，否则返回null
    return self::findOne(["access_token" => \$token]);
}
```
`HttpBearerAuth`会读取请求头中的`Access Token`，并使用上述方法进行权限认证，因此你必须在`common\models\User`中实现上述方法，如果返回`null`，表示`Access Token`无效，权限认证失败。

#### 请求参数认证 id=query-param-auth
如果你的api请求方是IOS、Android等应用程序，则可以选择这种认证方式。你需要提供登录入口让用户输入账号密码，然后请求服务端，认证通过后服务端会返回与登录账号对应的`Access Token`（通常保存在数据库中）。请求参数认证方式开启方法如下：
```php
namespace frontend\controllers;

use yii\\rest\Controller;
use yii\\filters\auth\QueryParamAuth;

/**
 * Api controller
 */
class ApiController extends Controller
{
    public function behaviors()
    {
        return [
            'authenticator' => [
                // 此处开启请求参数认证
                'class' => QueryParamAuth::className(),
            ],
        ];
    }

    public function actionUser(){
        return ["message" => "hello"];
    }
}
```

App在每次调用api接口时，需要把`access-token`当作一个请求参数用于权限认证，举例如下：
```php
curl -i "http://127.0.0.1:20080/api/user?access-token=2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH"
```

`QueryParamAuth`会自动读取`access-token`参数并进行校验，其过程和[HttpBearerAuth](#oauth2)认证`Access Token`的方式一致，两步完成配置：
1. 配置User认证组件：`frontend/config/main.php`
```php
return [
        ... ...
        'components' => [
            'user' => [
                'identityClass' => 'common\models\User',
            ],
            ... ...
        ]
];
```
2. 实现认证方法：`common\models\User`  
```php
public static function findIdentityByAccessToken(\$token, \$type = null)
{
        // 认证成功返回User对象，否则返回null
        return self::findOne(["access_token" => \$token]);
}
```

#### 综合认证 id=composite-auth
Yii允许你同时设置上述所有认证方法，你只需要完成如下配置即可：
```php
namespace frontend\controllers;

use yii\\rest\Controller;

/**
 * Api controller
 */
class ApiController extends Controller
{
    public function behaviors()
    {
        return [
            'authenticator' => [
                // 此处开启综合认证
                'class' => \yii\\filters\auth\CompositeAuth::class,
                'authMethods' => [
                    \yii\\filters\auth\HttpBasicAuth::class,
                    \yii\\filters\auth\HttpBearerAuth::class,
                    \yii\\filters\auth\QueryParamAuth::class,
                ],
            ],
        ];
    }

    public function actionUser(){
        return ["message" => "hello"];
    }
}
```
> **注意：** `HttpBasicAuth`和`HttpBearerAuth`不能同时设置，否则`HttpBasicAuth`会失效。

此时你可以同时使用上述三种认证方式进行权限认证，但是要保证`User`组件配置正确和实现`Access Token`认证方法：
```php
// OAuth2认证：POST请求数据
curl -X POST -H "Authorization:Bearer 2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH" "http://127.0.0.1:20080/api/user"
// OAuth2认证：GET请求数据
curl -H "Authorization:Bearer 2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH" "http://127.0.0.1:20080/api/user"

// 参数请求认证：POST请求数据
curl -X POST -i "http://127.0.0.1:20080/api/user?access-token=2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH"
// 参数请求认证：GET请求数据
curl -i "http://127.0.0.1:20080/api/user?access-token=2EfPO65IhLvhD5SgtY3TfHMbk3GuuRiH"
```

HTML,
    "限制接口访问频率" => <<<HTML
### 限制接口访问频率 id=rate-limiter
为防止滥用和攻击，你应该考虑对您的 API 限流。 例如，您可以限制每个用户 10 分钟内最多调用 API 100 次。 如果在规定的时间内接收了一个用户大量的请求，将返回响应状态代码 429 (这意味着过多的请求)。

要使用限速功能，需要满足如下两个条件：
- **实现接口**：`User`组件`user identity class`必须实现 [yii\filters\RateLimitInterface](https://www.yiichina.com/doc/api/2.0/yii-filters-ratelimitinterface)这个接口；
- **新增字段**：数据库`User`表需要新增两个字段用于保存接口使用情况；

#### 实现接口
一般来讲，我们在这个文件中配置`user identity class`：`frontend/config/main.php`
```php
return [
    ... ...
    'components' => [
        'user' => [
            'identityClass' => 'common\models\User',
        ],
        ... ...
    ]
];
```
在Yii框架给出的默认程序中，`common\models\User`已实现了`IdentityInterface`这个接口用于权限控制，因此你不能把它删除，为了实现限速功能，你还需要实现另外一个接口：
```php
namespace common\models;

use Yii;
use yii\db\ActiveRecord;
use yii\\filters\RateLimitInterface;
use yii\web\IdentityInterface;

class User extends ActiveRecord implements IdentityInterface, RateLimitInterface //实现限速接口
{
    ... ...

    public function getRateLimit(\$request, \$action)
    {
        return [100, 600]; // 600秒内最多调用100次
    }

    public function loadAllowance(\$request, \$action)
    {
        return [\$this->allowance, \$this->allowance_updated_at];
    }

    public function saveAllowance(\$request, \$action, \$allowance, \$timestamp)
    {
        \$this->allowance = \$allowance;
        \$this->allowance_updated_at = \$timestamp;
        \$this->save();
    }
}
```
#### 新增字段
由上代码可知，我们需要在`User`表中新增`allowance`和`allowance_updated_at`这两个字段，我们用 [migrate](/docs/structure/controller/console#migrate) 实现：
```ruby
root@898a462156ae:/app# yii migrate/create add_user_rate_limit
Yii Migration Tool (based on Yii v2.0.43)

Create new migration '/app/console/migrations/m211220_070340_add_user_rate_limit.php'? (yes|no) [no]:yes
New migration created successfully.

```
编辑数据迁移文件：`console/migrations/m211220_070340_add_user_rate_limit.php`
```php
use yii\db\Migration;

class m211220_070340_add_user_rate_limit extends Migration
{
    public function safeUp()
    {
        \$this->addColumn('{{%user}}', 'allowance', \$this->integer()->defaultValue(0)->notNull()->comment("在allowance_updated_at设定的时间达到之前还剩下多少次可以调用"));
        \$this->addColumn('{{%user}}', 'allowance_updated_at', \$this->integer(11)->defaultValue(0)->notNull()->comment("本周期结束的时间戳"));
        return true;
    }

    public function safeDown()
    {
        \$this->dropColumn('{{%user}}', "allowance");
        \$this->dropColumn('{{%user}}', "allowance_updated_at");
        return true;
    }
}

```
运行迁移文件：
```ruby
root@898a462156ae:/app# yii migrate/up
Yii Migration Tool (based on Yii v2.0.43)

Total 1 new migration to be applied:
        m211220_070340_add_user_rate_limit

Apply the above migration? (yes|no) [no]:yes
*** applying m211220_070340_add_user_rate_limit
    > add column allowance integer NOT NULL DEFAULT 0 COMMENT '在allowance_updated_at设定的时间达到之前还剩下多少次可以ser}} ... done (time: 0.051s)
    > add column allowance_updated_at integer(11) NOT NULL DEFAULT 0 COMMENT '本周期结束的时间戳' to table {{%user}} . done (time: 0.048s)
*** applied m211220_070340_add_user_rate_limit (time: 0.104s)


1 migration was applied.

Migrated up successfully.
```
此时，数据库中的`User`表已经添加好了两个字段，如果要迁移到测试/生产环境，别忘了执行迁移文件哦！！！

#### 限速测试
上述两步完成后Yii就已经可以进行接口限速了，我们调用如下接口：
```php
curl -i -H "Authorization:Bearer 3d129a891744b15ce76df408a786286e" "http://127.0.0.1:20080/api/user"
HTTP/1.1 200 OK
Date: Mon, 20 Dec 2021 07:22:19 GMT
X-Rate-Limit-Limit: 100
X-Rate-Limit-Remaining: 18
X-Rate-Limit-Reset: 492
Content-Length: 19
Content-Type: application/json; charset=UTF-8

{"message":"hello"}
```

如果超出速率限制，会报错如下：
```php
curl -i -H "Authorization:Bearer 3d129a891744b15ce76df408a786286e" "http://127.0.0.1:20080/api/user"
HTTP/1.1 429 Too Many Requests
Date: Mon, 20 Dec 2021 07:33:02 GMT
X-Rate-Limit-Limit: 1 
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Reset: 600
Content-Length: 131
Content-Type: application/json; charset=UTF-8

{"name":"Too Many Requests","message":"Rate limit exceeded.","code":0,"status":429,"type":"yii\\web\\TooManyRequestsHttpException"}
```

当速率限制被激活，默认情况下每个响应将包含以下 HTTP 头发送目前的速率限制信息：
- `X-Rate-Limit-Limit`：在一个周期中最多可以访问多少次；
- `X-Rate-Limit-Remaining`：在本周期中还有多少次可以调用；
- `X-Rate-Limit-Reset`：离恢复到最大访问次数还需要多少秒；

数据库中也记录了限速信息：
```sql
mysql> select allowance, allowance_updated_at from user where id = 1;
+-----------+----------------------+
| allowance | allowance_updated_at |
+-----------+----------------------+
|        18 |           1639984939 |
+-----------+----------------------+
1 row in set (0.00 sec)
```
> 注意：每次接口调用都会去Mysql中查找限速信息，在高并发应用中应该考虑缓存。

#### Redis缓存
在高并发的应用中是很忌讳频繁`IO`操作的（数据库读写），因此最好将速率信息记录到高速缓存中（如：redis），您可以参考如下代码：
```php
public function getRateLimit(\$request, \$action)
{
    return [100, 600]; // 600秒内最多调用100次
}

public function loadAllowance(\$request, \$action)
{
    return [
        Yii::\$app->redis->hget("api-rate-limit", \$this->id . ":allowance"),
        Yii::\$app->redis->hget("api-rate-limit", \$this->id . ":allowance_updated_at"),
    ];
}

public function saveAllowance(\$request, \$action, \$allowance, \$timestamp)
{
    Yii::\$app->redis->hset("api-rate-limit", \$this->id . ":allowance", \$allowance);
    Yii::\$app->redis->hset("api-rate-limit", \$this->id . ":allowance_updated_at", \$timestamp);
}
```
**启用redis容器：**  

首先你需要找到运行Yii的容器的网络：
```php
lvzmen:yii$ docker inspect cmslvzmencom_frontend_1 | grep network
            "NetworkMode": "cmslvzmencom_extnetwork",
```

启动redis容器的`--network`参数必须要和运行Yii的容器一致，且要指定同一网段的IP：
```php
docker run -itd --name iRedis \
--network cmslvzmencom_extnetwork \
--ip 172.23.0.8 \
redis \
--requirepass "123456" \
--appendonly yes
```
- `--network cmslvzmencom_extnetwork`：设置和Yii容器相同的网络，否则会自创一个网段，网段之间不能访问；
- `--ip 172.23.0.7`：设置静态IP，否则每次重启容器IP会变，修改Yii配置文件很麻烦；
- `--requirepass "123456"`：设置redis访问密码；
- `--appendonly yes`：保存数据到磁盘；

**安装Redis扩展：**  

如果您的框架还没有安装Redis依赖，需要运行如下命令安装：
```php
php composer.phar require --prefer-dist yiisoft/yii2-redis
```

**配置Yii：** 

配置文件如下：`common/config/dev/main.php`
```php
return [
    'components' => [
        'db' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=172.23.0.2;dbname=cms',
            'username' => 'root',
            'password' => 'verysecret',
            'charset' => 'utf8',
        ],
        'redis' =>[
            'class' => 'yii\\redis\Connection',
            'hostname' => '172.23.0.7',
            'port' => 6379,
            'password' => '123456',
            'database' => 0,
        ]
    ],
];
```

HTML,
    "响应" => <<<HTML

HTML

];

echo Markdown::widget(["content" => implode("\n", $html)]);

?>


